"""
Common functionality shared by search heuristic-type optimizers.
For example, you can use optimize to combine several optimizers to form
a hybrid optimizer, run an optimizer until some condition has been met, and
much more.

Copyright (C) 2013 Jerrad Michael Genson

This program is free software: you can redistribute it and/or modify
it under the terms of the BSD 3-Clause License as published by
the Open Source Initiative.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
BSD 3-Clause License for more details.

You should have received a copy of the BSD 3-Clause License
along with this program.  If not, see
<https://betelgeuse-ml.googlecode.com/hg/LICENSE>

"""

# Module header
__author__ = 'Jerrad Genson'
__contact__ = 'jerradgenson@gmail.com'
__copyright__ = 'Copyright 2013 Jerrad Michael Genson'
__license__ = 'BSD 3-Clause'


def combine_optimizers(optimizers, ratio):
    """
    Combine an iterable of optimizers into one hybrid optimizer.

    Args:
        optimizers: An iterable of optimizer functions.
                    Optimizer functions take one argument - a possible
                    solutions object - and returns a possible solutions object.
                    "Possible solutions" can be represented by almost anything
                    as long as all optimizers share the same representation.
        ratio: A list of integers specifying the ratio to call the optimizer
               functions. For a list of [2, 1], the first optimizer would be
               called twice, then the second optimizer would be called once.

    Returns:
        A hybrid optimizer function that takes a list of possible solutions as
        input and returns a list of possible solutions as output.

    """

    def composite_optimize(possible_solutions):
        """
        A hybrid optimizer function that takes possible solutions as input and
        returns possible solutions as output.

        Args:
            possible_solutions: Polymorphic. Can be almost anything as long as
                                all optimizers expect the same object.

        Returns:
            Possible solutions after running one cycle of the hybrid optimizer.
            One cycle of the hybrid optimizer is equal to N cycles of its
            component optimizers, where N is the sum of all elements of ratio.

        """

        # Apply each optimizer to problem states.
        for index, optimize in enumerate(optimizers):
            # Cycle each optimizer as specified by ratio.
            for iteration in range(ratio[index]):
                possible_solutions = optimize(possible_solutions)

        return possible_solutions

    return composite_optimize


def create_solutions(initial_solution, quantity, mutate):
    """
    Create multiple random solutions from one initial arbitrary solution

        Args:.
            initial_solution: Any arbitrary solution to the problem of study.
            quantity The number of solutions to create.
            mutate A mutation function that is compatible with initial_solution.
                   Should take two arguments - an object of the same type as
                   initial_solution, and a magnitude between 0 and 1 for the
                   amount of mutation to apply. Should return an object also
                   of the initial_solution type.

        Returns:
            A list of randomly-generated problem solutions..

    """

    possible_solutions = [mutate(initial_solution, 1) for solution in range(quantity)]
    return possible_solutions


def find_best(possible_solutions, check_quality):
    """
    Find the best possible solution of the ones given.

    Args:
        possible_solutions: An iterable of possible solutions.
        check_quality: A function that takes a possible solution as input and
                       returns the quality of the state as a real value
                       between 0 and 1.

    Returns:
        A tuple of (best_solution, solution_quality).

    """

    # Create a generator of (possible_solution, solution_quality) tuples.
    solution_qualities = ((solution, check_quality(solution)) for solution in possible_solutions)

    # Find the highest quality solution.
    return max(solution_qualities, key=lambda tup: tup[1])


def conditional_optimize(possible_solutions, optimize, max_iterations=None,
                         min_quality=None, check_quality=None):
    """
    Run an optimizer until certain conditions have been met - until the specified
    solution quality has been reached, or the maximum iterations have been
    exceeded, or a combination of both.

    Args:
        possible_solutions: An initial iterable of of possible solutions to optimize.
        optimize: An optimizer function that takes possible_solutions as input,
                  runs one iteration of its optimization algorithm on them, and
                  returns partially optimized possible_solutions as output.

    Keywords:
        max_iterations: The maximum number of iterations to run optimizer.
        min_quality: Stop running optimize when this solution quality has been
                     achieved. If this argument is given, a check_quality
                     argument must also be given.

    Returns:
        A fully-optimized possible_solutions, within the constraints of the
        conditions given.

    """

    # Loop until max_iterations is exceeded or min_quality is reached.
    count = 0
    while True:
        count += 1
        possible_solutions = optimize(possible_solutions)
        if max_iterations and count >= max_iterations:
            # max_iterations has been reached
            return possible_solutions

        current_best = find_best(possible_solutions, check_quality)
        if (min_quality and current_best[1] >= min_quality):
            # min_quality has been reached
            return possible_solutions


def swap(index1, index2, array):
    """
    Non-destructively swap two elements in an array-like object.

    Args:
        index1: The index of the first element.
        index2: The index of the second element.
        array: A Numpy array-like object.

    Returns:
        A copy of array with swapped elements.

    """

    array_copy = array.copy()
    array_copy[index1] = array[index2]
    array_copy[index2] = array[index1]

    return array_copy


def array_value_equal(array1, array2):
    """
    Compare two Numpy arrays for value equality.

    Args:
        array1: The first Numpy array to compare.
        array2: The second Numpy array to compare.

    Returns:
        True if all elements of array1 and array2 are equal.
        False if they are not.

    """

    try:
        return (array1 == array2).all()

    except AttributeError:
        return False


def array_in_arrays(array, arrays):
    """
    Check if a Numpy array is contained in an iterable.
    Comparison is done by reference.

    Args:
        array: A Numpy array to search array_list for.
        arrays: An iterable of Numpy arrays.

    Returns:
        True if the array is in the iterable. False if it is not.

    """

    for element in arrays:
        if element is array:
            return True

    return False


def remove_array(array, arrays):
    """
    Remove a Numpy array from a list of arrays.

    Args:
        array: The Numpy array to remove.
        arrays: The list of arrays to remove array from.

    Returns:
        A new list of arrays sans the target array.

    """

    # Construct a new list with all the elements of array_list
    # except for the target array.
    new_list = []
    for element in arrays:
        if element is not array:
            # Do not include target array in the new list.
            new_list.append(element)

    return new_list


def array_hash(array):
    """
    Create a single, numeric hash representing an array.

    Args:
        array: A Numpy array.

    Returns:
        A numeric hash.

    """

    return hash(tuple(array))
